基础库的字符串设计

C++在字符串上表现,一直以来很受人诟病,没有一个库的字符串类的表现能让人满意, std的string,mfc的CString,Qt的QString等等字符串类,都存在这样那样的问题,以至于字符串处理竟然成为C++的弱项,这也太不应该了。

话说回来,C++对待字符串的态度也很奇葩,其他语言都将字符串当做是内建类,在编译器层面来实现,只有C++是通过库的形式来提供字符串的抽象,也就是说,C++的语言层面上并没有字符串类,而是提供制造字符串轮子的语言机制。这样有两个好处,一方面既然这些语言机制能实现字符串,说不定还能干点别的什么事情,授人以鱼不如授人以渔;另一方面,更重要的是,字符串内部的缓冲区涉及到动态内存分配,一旦涉及到内存资源管理,在C++下从来就不是简单的事情,最明显的一点,就是稍微处理方式不恰当,就势必影响应用层对字符串全方位的粒度控制,这就违背了C++的语言哲学了。

虽说字符串类的设计不容易,C++面世到现在,未曾出现过差强人意的相关库即是明证。但也不至于困难到语言都面世几十年了,还在字符串处理上灰头土脸的地步。其实,只要牢牢把握以下几点,还是有希望做出来好用的字符串库的。

1、采用utf8编码,这个就不解释太多了,字符串不可能搞成支持多种字符编码的模板类(不必要的麻烦太多),所以自然要选择一种Unicode编码,在空间性能、时间性能、跨平台(大端小端)、api设计等这些因素,utf8的综合表现最为杰出。要操作其他编码的字符串时,都要求先临时转换为utf8编码,操作完成后,再将结果转回为原始编码的字符串,效率上稍微存在损失,但是真的不打紧;

2、空间效率与时间效率上的最高要求,哪怕由此导致api使用上的丑陋,也在所不惜。只因字符串的应用范围太广了,不能让它成为影响性能的因素。std的string之所以存在那么多的成员函数,还不是为了性能上的需要,其中大部分都只是函数重载,仅仅只是为了避免临时string的诞生,标准库string的这种应对方式也太实诚了

3、使用上的方便性,不必遵从什么最小完整接口的戒条,只管往里面添加各种各样的成员函数,怎么方便就怎么来,当然也不能无节制,QString就太过分了,啥都往里面塞。

有鉴于此,我们将string设计为两个类,U8View和U8String,分别代表只读字符串和字符缓冲区生命周期管理类,这类似于Java的String和StringBuilder,其实这也对应着C++17的string_view和string,这个方案既简单直观又灵活高效,奇怪的是,C++社区竟然要到17才会有这样的认识,并且,就算这样,std的字符串表现也还是垃圾。

U8View代表着一段连续字符的只读内存块,单个的char也可以看成是长度为1的U8View,不要求字符缓冲以0结束,这一点很关键,标准库的string就是在此放不开手脚,才导致那么蹩脚的设计。U8View很轻量级,它只有两个字段,分别代表字符缓冲的起始地址以及长度,常量字符串,U8String,字符数组这些内部有着连续字符缓冲的对象,全部都可以看成是U8View。U8View不得修改其缓冲区的任何内容。

显然,U8View有一个很好的特性,那就是U8View的任何一部分也都是U8View,因此,U8View提取子字符串的操作必然很轻量级,简直不存在任何性能上的损失,一步到位就做好了,无须搞任何内存分配的事情。所以,U8View自然就不必理会其缓冲区的生命周期,缓冲区的有效性由应用层代码来保证,U8View在其使用期间,都假设了字符缓冲的有效性。

bool IsValidUtf8Text(const char* str, size_t len);

struct U8View

{

typedef const char* const_pointer;

const_pointer mStr;

size_t mLength;

U8View(const_pointer str, size_t len) :mStr(str), mLength(len)

{

assert(IsValidUtf8Text(str, len));

}

U8View(const_pointer start, const_pointer last);

U8View(const_pointer str);

template U8View(const char(&str)[_Size]);

bool empty()const { return mLength == 0; }

char operator[](size_t index)const;

//...

};

U8View里面有大量的成员函数,大致可分为这6类,所有的成员函数几乎为const属性。

1、 构造函数,通过各种常量字符指针来初始化U8View,各个函数体内部要assert字符串utf8的有效性。

2、 查找函数,Find,RFind(查找字符,查找子字符串,正向查找,反向查找),FindOneOf查找字符串参数里面的任何一个unicode字符;

3、 比较函数,CompareTo大小写方式比较,CompareTo忽略大小写比较,StartsWith, EndsWith,各种比较操作符的重载函数,empty可空判断

4、 截取子字符串,不管时间空间,都是O(1),零惩罚。这里又分为3类,a)直接通过字节数来截取,Sub,Left,Right,Trim,TrimLeft,TrimRight,关键的一点是返回时候,都要assert其utf8字符串的完整性;b)以Unicode的codepoint数目来截取子字符串,SubPoints,LeftPoints等;c)以Unicde的glyph数目来截取。

5、 迭代器,正常的begin、end函数, Points返回一对访问codepoint的迭代器,Glyphs返回迭代glyph的迭代器,正向迭代,反向迭代;返回各个位置上(比如字符串头尾)的char值或者codepoint值

6、 生成U8String:Replace替换已有子字符串以生成新的U8String,U8View的加号操作符重载,Concat多个U8View等。

至于U8View转换为各类数字类型的转换函数,就不在这里出现了,另有更好的安排。有了U8View之后,基本上所有同步函数的字符串参数,都可以为U8View的类型。其实,很多字符串运算的场合下,只要U8View就完全足够,效率(时间空间)以及易用性,均能达到最优。U8View其实为值类型,C++在抽象值类型时,不管是性能还是api使用形式,每一项的表现都很杰出,其他任何猿语都很难再超过大C++了。

由于U8View已经剥离出来很多常见的字符串操作,U8String的设计就很简单了,只需老老实实本本分分管理字符缓冲的生命周期就好了,也就是实现析构函数,复制移动构造函数,赋值操作符重载,这没什么好说的,只是要注意到U8String内部的字符缓冲,必须以0结束。U8String的成员函数无非就是替换,追加,修改,交换等。其中很重要的一个成员函数View,用以返回U8View对象,之后就能以只读形式操作U8String的字符缓冲。这里要强调的一点就是,除了移动构造和移动赋值函数,所有其他成员函数的字符串参数类型都是U8View类型,因此我们就无须为了效率,要对每一个操作都重载那么多成员函数,何其简洁。

不知道大家发现没有,U8View和U8String这两个类型互相依赖,你中有我,我中有你,相依为命。这种相互纠缠的设计方案,一般情况下避之唯恐不及,但是在字符串这里必须这样搞,用起来才方便。但是,编译器可不喜欢这种设计,这可怎么办?只好让U8View成为U8String的内部类,并将其命名为TextView。然后再在外面将U8String的TextView typedef为U8View,代码如下。

struct U8String

{

struct TextView

{

char* mStr;

size_t mLength;

//...

};

MemoryAllocator* mAlloc;

size_t mCapacity;

char* mStr; //故意保持跟TextView一样的内存布局

size_t mLength;

//...

};

typedef U8String::TextView U8View;

字符串的故事就这样结束了吗?不,这才刚刚开始,U8String U8View必须配套格式化,字符串解析,字符串流的输入输出,正则表达式(运行时以及编译时)等功能之后,才真正好用起来,然后就可以发现,C++其实还是很擅长于处理字符串的。但是,在此之前,我们必须先解决几个基础问题,那就是反射、Trait(非侵入式的接口)、allocator、甚至stl各个容器的重新设计。

本页共54段,3711个字符,8313 Byte(字节)